feat: self-hosted backend URL override in Developer Settings#6604
feat: self-hosted backend URL override in Developer Settings#6604Rahulsharma0810 wants to merge 3 commits intoBasedHardware:mainfrom
Conversation
Adds a 'Self-Hosted Backend' section to Developer Settings that lets users point the app at their own backend without rebuilding from source. Changes: - SharedPreferencesUtil: add customBackendUrl getter/setter - main.dart: restore saved URL via Env.overrideApiBaseUrl() on launch - developer.dart: UI with text field, save/clear, and restart reminder The infrastructure was already in place (Env.overrideApiBaseUrl() and _apiBaseUrlOverride in env.dart) — this simply exposes it in the UI. Backend requirement: set LOCAL_DEVELOPMENT=true to bypass Firebase token verification when the app's tokens are for a different Firebase project than the self-hosted backend. Closes BasedHardware#842 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR adds a self-hosted backend URL override to Developer Settings, wiring a new
Confidence Score: 4/5Not safe to merge as-is — two P1 defects can break API calls or silently ignore the user's custom URL. Two P1 issues remain: TestFlight staging always overwrites a custom backend URL, and the clear button immediately corrupts the live Env state with an empty-string override. Both are straightforward to fix but affect core functionality of the feature being added. app/lib/main.dart (TestFlight ordering) and app/lib/pages/settings/developer.dart (clear button Env call) Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[App Launch: _init] --> B[SharedPreferencesUtil.init]
B --> C{customBackendUrl not empty?}
C -- Yes --> D[Env.overrideApiBaseUrl customBackend]
C -- No --> E{F.env == prod?}
D --> E
E -- Yes --> F{isTestFlight?}
E -- No --> Z[App Ready]
F -- No --> Z
F -- Yes --> G[Env.isTestFlight = true]
G --> H{testFlightUseStagingApi?}
H -- No --> Z
H -- Yes --> I[Env.overrideApiBaseUrl stagingUrl]
I --> Z
style I fill:#c0392b,color:#fff
style D fill:#27ae60,color:#fff
|
| onPressed: () { | ||
| _backendUrlController.clear(); | ||
| SharedPreferencesUtil().customBackendUrl = ''; | ||
| Env.overrideApiBaseUrl(''); | ||
| setState(() {}); | ||
| AppSnackbar.showSnackbar('Backend URL cleared — restart app to apply'); | ||
| }, |
There was a problem hiding this comment.
Clear button sets URL to empty string, breaking live API calls
Env.overrideApiBaseUrl('') sets _apiBaseUrlOverride = ''. Because Env.apiBaseUrl uses _apiBaseUrlOverride ?? _instance.apiBaseUrl, an empty string is non-null and passes the ?? check, so all API calls from this point forward will construct requests against '' and fail immediately — before the required restart.
The snackbar already communicates that a restart is needed, so the live Env.overrideApiBaseUrl call on clear is not just redundant but actively harmful. Remove it:
| onPressed: () { | |
| _backendUrlController.clear(); | |
| SharedPreferencesUtil().customBackendUrl = ''; | |
| Env.overrideApiBaseUrl(''); | |
| setState(() {}); | |
| AppSnackbar.showSnackbar('Backend URL cleared — restart app to apply'); | |
| }, | |
| onPressed: () { | |
| _backendUrlController.clear(); | |
| SharedPreferencesUtil().customBackendUrl = ''; | |
| setState(() {}); | |
| AppSnackbar.showSnackbar('Backend URL cleared — restart app to apply'); | |
| }, |
| // Self-Hosted Backend Section | ||
| _buildSectionHeader( | ||
| 'Self-Hosted Backend', | ||
| subtitle: 'Override the API URL to use your own backend instead of api.omi.me. ' | ||
| 'Leave empty to use the default. Requires app restart to take effect.', | ||
| ), | ||
| Container( | ||
| padding: const EdgeInsets.all(16), | ||
| decoration: BoxDecoration(color: const Color(0xFF1C1C1E), borderRadius: BorderRadius.circular(14)), | ||
| child: Column( | ||
| crossAxisAlignment: CrossAxisAlignment.start, | ||
| children: [ | ||
| Row( | ||
| children: [ | ||
| Container( | ||
| width: 40, | ||
| height: 40, | ||
| decoration: BoxDecoration( | ||
| color: const Color(0xFF2A2A2E), | ||
| borderRadius: BorderRadius.circular(10), | ||
| ), | ||
| child: Center( | ||
| child: FaIcon(FontAwesomeIcons.server, color: Colors.grey.shade400, size: 16), | ||
| ), | ||
| ), | ||
| const SizedBox(width: 14), | ||
| Expanded( | ||
| child: Column( | ||
| crossAxisAlignment: CrossAxisAlignment.start, | ||
| children: [ | ||
| const Text( | ||
| 'Backend URL', | ||
| style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), | ||
| ), | ||
| const SizedBox(height: 2), | ||
| Text( | ||
| Env.apiBaseUrl ?? 'https://api.omi.me', | ||
| style: TextStyle(color: Colors.grey.shade500, fontSize: 12), | ||
| overflow: TextOverflow.ellipsis, | ||
| ), | ||
| ], | ||
| ), | ||
| ), | ||
| ], | ||
| ), | ||
| const SizedBox(height: 16), | ||
| TextField( | ||
| controller: _backendUrlController, | ||
| style: const TextStyle(color: Colors.white, fontSize: 14), | ||
| decoration: InputDecoration( | ||
| hintText: 'https://your-backend.example.com', | ||
| hintStyle: TextStyle(color: Colors.grey.shade600, fontSize: 14), | ||
| filled: true, | ||
| fillColor: const Color(0xFF2A2A2E), | ||
| border: OutlineInputBorder( | ||
| borderRadius: BorderRadius.circular(10), | ||
| borderSide: BorderSide.none, | ||
| ), | ||
| contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), | ||
| suffixIcon: _backendUrlController.text.isNotEmpty | ||
| ? IconButton( | ||
| icon: Icon(Icons.clear, color: Colors.grey.shade500, size: 18), | ||
| onPressed: () { | ||
| _backendUrlController.clear(); | ||
| SharedPreferencesUtil().customBackendUrl = ''; | ||
| Env.overrideApiBaseUrl(''); | ||
| setState(() {}); | ||
| AppSnackbar.showSnackbar('Backend URL cleared — restart app to apply'); | ||
| }, | ||
| ) | ||
| : null, | ||
| ), | ||
| onChanged: (_) => setState(() {}), | ||
| keyboardType: TextInputType.url, | ||
| autocorrect: false, | ||
| ), | ||
| const SizedBox(height: 12), | ||
| SizedBox( | ||
| width: double.infinity, | ||
| child: ElevatedButton( | ||
| onPressed: () { | ||
| final url = _backendUrlController.text.trim().replaceAll(RegExp(r'/+$'), ''); | ||
| if (url.isNotEmpty && !url.startsWith('http')) { | ||
| AppSnackbar.showSnackbar('URL must start with http:// or https://'); | ||
| return; | ||
| } | ||
| SharedPreferencesUtil().customBackendUrl = url; | ||
| Env.overrideApiBaseUrl(url.isNotEmpty ? url : Env.apiBaseUrl ?? ''); | ||
| setState(() {}); | ||
| AppSnackbar.showSnackbar( | ||
| url.isEmpty ? 'Restored default backend — restart app to apply' : 'Backend URL saved — restart app to apply', | ||
| ); | ||
| }, | ||
| style: ElevatedButton.styleFrom( | ||
| backgroundColor: const Color(0xFF2A2A2E), | ||
| foregroundColor: Colors.white, | ||
| padding: const EdgeInsets.symmetric(vertical: 12), | ||
| shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), | ||
| elevation: 0, | ||
| ), | ||
| child: const Text('Save Backend URL', style: TextStyle(fontWeight: FontWeight.w500)), | ||
| ), | ||
| ), | ||
| const SizedBox(height: 10), | ||
| Text( | ||
| 'Note: your backend must have LOCAL_DEVELOPMENT=true to accept tokens from the Omi app.', | ||
| style: TextStyle(color: Colors.grey.shade600, fontSize: 11), | ||
| ), | ||
| ], | ||
| ), | ||
| ), |
There was a problem hiding this comment.
Hardcoded user-facing strings must use l10n
All user-visible strings in this section are hardcoded English. The codebase rule requires context.l10n.keyName for every user-facing string — there are at least 8 violations in this block:
'Self-Hosted Backend'(section header)'Backend URL'(row title)'https://your-backend.example.com'(hint text)'Save Backend URL'(button label)'Note: your backend must have LOCAL_DEVELOPMENT=true…''Backend URL cleared — restart app to apply''URL must start with http:// or https://''Restored default backend — restart app to apply'/'Backend URL saved — restart app to apply'
Add the corresponding keys to app/lib/l10n/app_en.arb and provide translations for all 33 non-English locales per the project's localization policy.
Context Used: Flutter localization - all user-facing strings mus... (source)
| String get customBackendUrl => getString('customBackendUrl'); | ||
|
|
||
| set customBackendUrl(String value) => saveString('customBackendUrl', value); |
There was a problem hiding this comment.
customBackendUrl placed in the Auth section
This getter/setter was added inside the //------------------------ Auth ------------------------------------// block, but it belongs with the other developer settings under //---------------------- Developer Settings ---------------------------------// (around line 116). Moving it keeps the file's organization coherent and avoids confusion for future readers who look for dev-only preferences in the Auth block.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
- main.dart: skip TestFlight staging override when custom backend is
already set (user intent takes priority over TestFlight defaults)
- developer.dart: remove Env.overrideApiBaseUrl('') from clear/save
handlers — empty string is not null so the ?? fallback never fires,
breaking live API calls; changes now only persist to SharedPreferences
and take effect on restart as documented in the UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- preferences.dart: move customBackendUrl getter/setter from Auth section to Developer Settings section where it belongs - developer.dart: replace all hardcoded strings with context.l10n keys - app_en.arb: add 9 new l10n keys for the self-hosted backend UI (selfHostedBackendSectionTitle, selfHostedBackendSubtitle, selfHostedBackendUrlHint, selfHostedBackendSaveButton, selfHostedBackendSavedRestart, selfHostedBackendClearedRestart, selfHostedBackendRestoredRestart, selfHostedBackendHttpError, selfHostedBackendNote); reuses existing backendUrlLabel key Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
All Greptile-flagged issues have been addressed across commits 2 and 3. For clarity: P1 — P1 — TestFlight staging overrides custom URL (commit P2 — Hardcoded strings (commit P2 — Greptile's inline comments appear to be cached from earlier commits — the flagged code no longer exists in the current HEAD. |
Self-hosted deployments previously had no proper authentication path. Firebase token verification requires knowing the project ID, but the backend required a full service account (SERVICE_ACCOUNT_JSON) to initialize Firebase Admin — credentials only Omi possesses. Firebase token verification is asymmetric: the Admin SDK verifies JWTs using Firebase's public signing keys (https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com) and only needs the project ID, not any private credentials. This change adds a FIREBASE_PROJECT_ID initialization path so self-hosters can verify tokens from any Firebase project (including Omi's own, whose project ID `based-hardware-dev` is publicly committed in the repository) without needing a service account. Combined with the custom backend URL feature (PR BasedHardware#6604), this gives self-hosters a complete, proper authentication path with zero app rebuilding required. Also fixes LOCAL_DEVELOPMENT bypass missing from get_current_user_id() in dependencies.py — endpoints.py had it but this function did not, causing auth failures in some code paths during local development. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Problem
Self-hosting the Omi backend is documented and supported, but the iOS app hardcodes
api.omi.me— making it impossible to use a self-hosted backend without maintaining a full fork of the app.This was raised in #842 and closed citing "e2e authentication" as the blocker. That reasoning is incomplete:
LOCAL_DEVELOPMENT=truein the backend (endpoints.py) — self-hosters can enable this flagdart:iousing BoringSSL (its own TLS stack), which ignores iOS's system certificate store. Even with a custom CA installed and trusted in iOS Settings, the app rejects it withCERTIFICATE_VERIFY_FAILED. The only fix is using a domain you control — which requires changing the hardcoded URLSolution
Add a Self-Hosted Backend section to Developer Settings with a URL text field. The infrastructure was already 90% in place:
This PR simply wires a UI to that existing method.
Changes
app/lib/backend/preferences.dart— addcustomBackendUrlgetter/setter (1 key in SharedPreferences)app/lib/main.dart— restore saved URL viaEnv.overrideApiBaseUrl()on launch, before any network callsapp/lib/pages/settings/developer.dart— new "Self-Hosted Backend" section with text field, save, and clearBehaviour
https://my-backend.example.comin Developer Settings → Self-Hosted Backendmain.dartrestores the URL before any network callapi.omi.meA note in the UI tells users their backend needs
LOCAL_DEVELOPMENT=truefor Firebase token bypass.Who needs this
What this is NOT
This does not remove or weaken any existing auth. It only lets users redirect API calls to a backend they control. The backend is still responsible for its own auth decisions.
Closes #842